import math
import random
import copy
import functools
import nrchoice
from timing import time_it

class Similarity:
    '''Class with different methods for calculating the similarity between two
    blogs.
    data ->  dict containing all the data, in the form
        {'Blog_name': { 'Word1': numeric_count, 
                        'Word2': numeric_count, }
        'Blog_name1': { 'Word1': numeric_count, 
                        'Word2': numeric_count, }
        'Blog_name2': { 'Word1': numeric_count, 
                        'Word2': numeric_count, }}
        TODO: Instead of using a data dictionary as a parameter, might as well
        use a database. 
        
    sim_fun ->    Function that calculates the similarity between two blogs, returning
                  a number between 0 and 1. Default is sim_distance or euclid distance.
    search_fun->  The default search function for finding similarities between blogs,
                  it is used by the top_similar function to calculate the top similar
                  blogs. Default is simulated annealing.
    '''
    
    def __init__(self, data, sim_fun=None, search_fun=None):
        '''Similarity class constructor. See the class documentation for more info.'''
        self.data = data
        self.search_fun = search_fun or self.__top_similar_annealing
        self.sim_fun = sim_fun or self.sim_distance
        
    def sim_distance(self, blogA, blogB, same_words=False):
        '''Return the similarity distance between the preferences of two persons. 
        This numbers ranges from 0 to 1, where 1 means that the two persons have the
        exact same preferences.
        
        blogA -> string representing a blog name, it must be in data
        blogB -> same as blogA
        same_words -> flag that indicates if the words that are only in the two
        blogs should be used for calculating the distance.
        '''
        assert blogA in self.data, "%s is not present in the data" % blogA
        assert blogB in self.data, "%s is not present in the data" % blogB
        
        blogA_words = set(self.data[blogA].keys())
        blogB_words = set(self.data[blogB].keys())
        # If there aren't any words to compare with, return similarity 0
        if not (blogA_words and blogB_words): return 0.0
        # Words that are going to count for calculating the distance
        words = blogA_words.intersection(blogB_words) if same_words \
                else blogA_words.union(blogB_words)
        
        total = 0.0
        for word in words:
            blogA_count = self.data[blogA].get(word, 0)
            blogB_count = self.data[blogB].get(word, 0)
            
            total += (blogA_count - blogB_count) ** 2
        return 1/(1+math.sqrt(total)) # Return a value between 0 and 1
    
    def top_similar(self, blogurl, n=10):
        '''Function that returns the top N similar blogs, it can be given the
        comparison algorithm, default is simulated annealing.'''
        return self.search_fun(blogurl, n)
    @time_it
    def __top_similar_annealing(self, blog_url, n=10, choice_fun=nrchoice.no_repeat_choice):
        '''Same as top_similar_brute but it uses a simulated annealing technique.'''
        # Initial temperature starts at 1.0
        temp = 1.0
        # Temperature variation 
        alpha = 0.99
        # Temperature limit
        TEMP_LIMIT = 0.000001
        # Number of iterations to spend in each temperature change
        K = 2
        # Curry for the similarity distance function
        sim_fun = functools.partial(self.sim_fun, blog_url)
        # Select some random items for the result
        blogs = self.data.keys()
        result = []
        for _ in range(n):
            election = choice_fun(blogs)
            result.append((sim_fun(election), election))
        result.sort()
        
        # Get the sum of the similarities between the blog_url the other
        # randomly selected blogs
        similarity_sum = sum([sim for sim, _ in result])
        # A simple loop count
        count = 0
        
        # Do the simulated annealing process while we can go down in temperature
        # and there are still options to test (for non repeating choices)
        while temp >= TEMP_LIMIT and len(blogs) > 0:
            # Copy the result so we don't change the actual solution
            result_copy = copy.copy(result)                        
            # Get a random new element
            new_election = choice_fun(blogs)
            new_similarity = sim_fun(new_election)
            # Change the min element in the result (result should always be sorted)
            result_copy[0] = (new_similarity, new_election)
            # Calculate the new similarity value
            copy_similarity_sum = sum([sim for sim, _ in result_copy])
            # Calculate the probability of accepting the state
            p = math.e ** ((-similarity_sum-copy_similarity_sum)/temp)
            # If the new similarity value is better, or the probability 
            # is better than some random number (metropolis algorithm)
            # make it the new result
            if (random.random() < p) or (copy_similarity_sum > similarity_sum):
                result = result_copy
                result.sort()
                similarity_sum = copy_similarity_sum
                
            if(count % K == 0):
                temp *= alpha
            count += 1
            
        return result
    @time_it
    def __top_similar_brute(self, blog_url, n=10):
        ''' Get the top N similar blogs. This algorithm uses brute force and 
        compares the blogs with everything, so the larger the data set, 
        the more time it takes to finish.
        This function is used as the search_fun parameter for the top_similar function.'''
        # Curry for the similarity distance function
        sim_fun = functools.partial(self.sim_fun, blog_url)
        # Calculate the similarity with every other blog
        similarities = [(sim_fun(url), url) for url in self.data.keys()]
        similarities.sort()
        return similarities[-n:]